iT邦幫忙

2025 iThome 鐵人賽

DAY 25
1
Rust

Bevy Rogue-lite 勇者冒險篇 × Rust 遊戲開發筆記系列 第 25

進度與關卡紀錄系統

  • 分享至 

  • xImage
  •  

既然遊戲中有關卡的設定,所以我想應該可以加個存檔的機制,所以接下來要解決回來繼續玩這件事,需要加入主選單、暫停選單,以及能夠儲存目前關卡與角色狀態的存檔。這篇記錄我如何在 Bevy 裡落實一個最小可行的進度系統。

這回的重點:

  • 建立 GameSession 資源與 SessionPlugin,集中管理主選單、暫停選單、遊戲階段狀態。
  • 把輸入、移動、耐力等系統都串聯 GameSession::is_playing(),避免暫停時還能操作。
  • 定義 GameSaveData,序列化玩家 HP / 等級 / 裝備與關卡索引,寫入 saves/slot1.json
  • 完成存檔與讀檔流程:Pause 選單儲存目前進度,進入遊戲後可讀取復原關卡與角色設定。

GameSession 主選單與遊戲階段的核心

new_demo/src/resources/game_session.rs 新增 GameSession 資源,負責記錄目前階段與選單實體位置:

#[derive(Resource, Debug, Default)]
pub struct GameSession {
    phase: GamePhase,
    pub main_menu_root: Option<Entity>,
    pub pause_menu_root: Option<Entity>,
}

impl GameSession {
    pub const SAVE_DIRECTORY: &'static str = "saves";
    pub const SAVE_SLOT_FILE: &'static str = "saves/slot1.json";

    pub fn phase(&self) -> GamePhase {
        self.phase
    }

    pub fn set_phase(&mut self, phase: GamePhase) {
        self.phase = phase;
    }

    pub fn is_playing(&self) -> bool {
        matches!(self.phase, GamePhase::Playing)
    }
}

GamePhase 很單純:MainMenu / Playing / PausedSessionPluginStartup 建立主選單,並在 Update 週期內處理各種互動:

app.init_resource::<GameSession>()
    .add_event::<StartNewGameEvent>()
    .add_event::<RequestLoadGameEvent>()
    .add_event::<RequestSaveGameEvent>()
    .add_event::<ResumeGameplayEvent>()
    .add_systems(Startup, spawn_main_menu)
    .add_systems(
        Update,
        (
            handle_main_menu_interactions,
            activate_gameplay_after_start.after(handle_main_menu_interactions),
            handle_pause_menu_interactions,
            process_save_game_requests.after(handle_pause_menu_interactions),
            process_load_game_requests
                .after(handle_main_menu_interactions)
                .after(handle_pause_menu_interactions)
                .after(process_save_game_requests),
            toggle_pause_menu_on_escape,
            resume_gameplay
                .after(handle_pause_menu_interactions)
                .after(toggle_pause_menu_on_escape)
                .after(process_load_game_requests),
        ),
    );

主選單

  • 主選單使用 UI Node 產生,按下「新遊戲」會寫入 StartNewGameEvent,轉為 Playing 並拆除選單畫面。
  • Esc 會切換暫停選單;在暫停狀態下排除大部分輸入,等玩家按下 Resume 再繼續。
  • 修改 new_demo/src/main.rs,讓 SessionPlugin 成為整個遊戲啟動時的第一個插件。

為了讓 UI 在 Pause 時不被遮蔽,我另外把選單的字型改成支援 CJK 的 IBMPlexSansJP-Regular.ttfassets/fonts/),並更新 MENU_FONT_PATH 常數,確保「新遊戲」與「讀取進度」可以正確顯示中文。


系統 gating 暫停時禁止輸入與回復

有了 GameSession::is_playing() 之後,所有會受到暫停影響的系統都要先檢查狀態。例如 movement_system

pub fn movement_system(
    keyboard_input: Res<ButtonInput<KeyCode>>,
    session: Res<GameSession>,
    mut query: Query<( &mut Transform, &mut Velocity, &mut PlayerFacing, &mut InputVector ),
                     (With<Player>, Without<PlayerDead>)>,
    time: Res<Time>,
) {
    if !session.is_playing() {
        return;
    }
    // 原本的移動邏輯...
}

暫停選單

同樣的判斷也套用在包含攻擊、耐力回復、中毒計時、 Debug 熱鍵等系統上。特別是敵人 AI、魔法球移動與接觸傷害也都改成先檢查 is_playing()——這解決了進入主選單或暫停時,史萊姆仍會追過來偷打的 bug,現在 Pause 菜單真正做到「全體靜止」。好處是:

  • 在主選單和 Pause 畫面中完全不會發生角色移動/耐力還在回復的怪現象。
  • GameSession 變成所有 gameplay 系統共享的 gating,後續要做過場動畫或多人暫停也可以沿用這種 pattern。

input_system 也先檢查 session.is_playing(),避免暫停時還能互動傳送門或開箱,整個遊戲流程因此保持一致。


GameSaveData 序列化玩家與關卡資訊

進度檔使用 serde + serde_jsonnew_demo/Cargo.toml 因此加入相關依賴。資料結構在 src/resources/save_data.rs

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameSaveData {
    pub version: u32,
    pub level_index: usize,
    pub player_health: i32,
    pub player_max_health: i32,
    pub player_level: usize,
    pub player_experience: u32,
    pub equipped_weapon: Option<WeaponKind>,
    pub equipped_shield: Option<ShieldKind>,
}

WeaponKindShieldKind 也各自 derive Serialize/Deserialize。目前只開一個 slot1.json,資料夾為 saves/;若未來要做多個存檔就能沿用這份結構。

儲存流程

Pause 選單按下「儲存進度」時會觸發 process_save_game_requests

if matches!(session.phase(), GamePhase::MainMenu) {
    warn!("主選單狀態下無法儲存進度");
    return;
}

let mut data = GameSaveData::new();
data.level_index = level_state.current_index();
data.player_health = health.current;
data.player_max_health = health.max;
data.player_level = progression.level;
data.player_experience = progression.experience;
data.equipped_weapon = weapon.map(|w| w.kind);
data.equipped_shield = shield.map(|s| s.kind);

fs::create_dir_all(GameSession::SAVE_DIRECTORY)?;
fs::write(GameSession::SAVE_SLOT_FILE, serde_json::to_string_pretty(&data)?)?;

存檔

  • 只有在 PlayingPaused 階段才允許儲存,避免一開始沒有玩家就寫出空檔案。
  • 紀錄玩家目前血量&最大血量,是為了之後調整裝備或 buff 時仍能回復到正確上限。
  • serde_json::to_string_pretty 讓檔案易讀,方便日後 Debug。

讀檔流程重建關卡與角色狀態

讀檔

讀檔按鈕(主選單或暫停選單)會觸發 RequestLoadGameEvent,由 process_load_game_requests 接手:

  1. 確認檔案存在:沒有 slot1.json 的時候會直接顯示警告,如果是在主選單觸發會把主選單重建回來。
  2. 反序列化與版本檢查:目前版本寫死為 1,不合的時候先警告再嘗試載入。
  3. 重建玩家狀態
    • LevelState::set_current_index() + LevelBuildContext.pending_layout = Some(index) 讓關卡系統重新排程該關。
    • 更新 PlayerProgressionHealthAttackDefense,並重新載入對應階段的玩家 sprite。
    • weapon_events.write(WeaponEquipEvent { kind }) 讓既有的裝備系統處理貼圖 / 攻擊加成。
    • 如果存檔沒有盾牌,而玩家正戴著盾,會移除 component 並調整防禦加成。
    • 清掉 PlayerDeadPoisoned、死亡畫面 overlay,確保讀檔後角色是可操控狀態。
  4. 更新 GameSession:主選單和 Pause 選單都關閉,session.set_phase(GamePhase::Playing)
  5. 留下紀錄:用 info! 印出目前關卡、HP、裝備,方便在 log 追蹤。

讀檔流程最棘手的是與關卡系統的配合。因為原本就有 LevelBuildContext 控制排程,所以只要塞回 pending_layout 即可沿用既有的流程,不需要另外手動產生地板/敵人。


小記

目前已經可以做到單一存檔,而且也能讀取檔案,後續如果想要多加一些功能的話,可能會加上這些:

  • 增加多存檔 slot、紀錄檔案的更新時間,甚至在主選單顯示說明。
  • 存檔格式換成壓縮/加密版本,並加入錯誤回復流程,防止檔案損壞。

今日程式碼同步至 repo


上一篇
巫師 Boss 的遠程攻擊
下一篇
UI 介面統整與圖像化
系列文
Bevy Rogue-lite 勇者冒險篇 × Rust 遊戲開發筆記26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言